-
-
Notifications
You must be signed in to change notification settings - Fork 11
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: Add rule types #110
feat: Add rule types #110
Conversation
packages/core/src/types.ts
Outdated
/** | ||
* The definition of an ESLint rule. | ||
*/ | ||
export interface RuleDefinition<T extends RuleVisitor = RuleVisitor> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using a type parameter here allows language plugins to insert their own RuleVisitor
interface for better type checking. I imagine we can turn the one in @types/eslint
into a RuleVisitor
so those definitions will still work.
This is really the only difference between rules for different languages.
ping @eslint/eslint-tsc @JoshuaKGoldberg |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry for the delay, and thanks for the ping! I still don't feel very confident reviewing functionally (#73), but have made some educated inferences just for what already exists in ESLint.
packages/core/src/types.ts
Outdated
export interface RuleTextEditor { | ||
/** | ||
* Inserts text after the specified node or token. | ||
* @param nodeOrToken The node or token to insert after. | ||
* @param text The edit to insert after the node or token. | ||
*/ | ||
insertTextAfter(nodeOrToken: object, text: string): RuleTextEdit; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The PRs in this repo are probably the heaviest users of : object
I've seen this year 😄. I'm getting the impression types using them are split across two use cases:
- "Base" (generalized) types meant to be the constraints on type parameters, such as the current
RuleVisitor
- Generic types where there's a clear injectable, shared type
For "Base" (generalized) types, it makes sense to use types like object
and unknown
. It's also common to name them something like Any*
or *Base
to make it clear that they're base types, not the ones end users directly use. Some of the types here are named like that, but some aren't - is that intentional?
For generic types, it'd be more typical to use a type parameter. That way consumers of the type can substitute in their intended types, instead of having to do some extends
strategy. Which would be very inconvenient for types like this with a lot of : object
s!
Is there anything blocking making this type generic?
export interface RuleTextEditor { | |
/** | |
* Inserts text after the specified node or token. | |
* @param nodeOrToken The node or token to insert after. | |
* @param text The edit to insert after the node or token. | |
*/ | |
insertTextAfter(nodeOrToken: object, text: string): RuleTextEdit; | |
export interface RuleTextEditor<NodeOrToken> { | |
/** | |
* Inserts text after the specified node or token. | |
* @param nodeOrToken The node or token to insert after. | |
* @param text The edit to insert after the node or token. | |
*/ | |
insertTextAfter(nodeOrToken: NodeOrToken, text: string): RuleTextEdit; |
Note that the same logic could be extended to any place that includes : object
or : unknown
.
ViolationLocation
'snode
: which would makeViolationReport
generic, which would makeRuleContext
generic.RuleContext
'soptions
RuleVisitor
'snode
andparent
- though that type looks like it might be more of a "Base" generalized type?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In this case, there's only ever one instance of RuleTextEditor
, which exists in the core and needs to work on everything...would that still be a case for a generic? 🤔
(Still trying to wrap my brain around this.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Type parameters are for when you want to substitute out different parts of a type for different uses. If there's only ever one instance of something, then you likely don't need type parameters - but then the type for nodeOrToken
should be known as something more specific than object
, right?
If the type for nodeOrToken
is always going to be some known thing, then a different suggestion could be:
export interface RuleTextEditor { | |
/** | |
* Inserts text after the specified node or token. | |
* @param nodeOrToken The node or token to insert after. | |
* @param text The edit to insert after the node or token. | |
*/ | |
insertTextAfter(nodeOrToken: object, text: string): RuleTextEdit; | |
// or some existing type, or some union thereof... | |
export interface EditableNodeOrToken { | |
/* ... */ | |
} | |
export interface RuleTextEditor { | |
/** | |
* Inserts text after the specified node or token. | |
* @param nodeOrToken The node or token to insert after. | |
* @param text The edit to insert after the node or token. | |
*/ | |
insertTextAfter(nodeOrToken: EditableNodeOrToken, text: string): RuleTextEdit; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To fully flesh out what we have here: this same RuleTextEditor
is used inside the core for applying fixes to any language that ESLint is linting (via a Language
object). So for JavaScript, it works on ESTree(ish) nodes, while for Markdown it works on Unist nodes. While these two types of nodes only shared a type
, there is no guarantee that any particular language will have that.
So, a rule is passing some representation of a node (or token) into these methods on RuleTextEditor
. ESLint itself doesn't really need to know what those are, because we just pass them back into Language#getRange()
to get the range information for that node or token.
🤔 I suppose the audience for types here would be the rule developer, who may not be able to tell what to pass in. In that case, I could see it being helpful to specify the type as a parameter...but that would then bubble up into RuleContext
and then up to RuleDefinition
. Maybe that's not a bad thing in TypeScript land?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍 if the RuleContext
cares about the type of nodes, and RuleDefinition
therefore does as well, then it makes sense that RuleDefinition
would have a type parameter for the kind of nodes.
In other words: if some consumer of RuleContext
/RuleDefinition
wanted to write a function that takes one of those in...
// js version
function workOnContextNode(context: RuleContext) {
context.sourceCode.ast.exampleProperty; // (or some equivalent)
}
...then TS users will want to be able to have that be well-typed without an as
.
interface MyLanguageRootAST {
exampleProperty: true;
}
function workOnContextNode(context: RuleContext<MyLanguage>) {
context.sourceCode.ast.exampleProperty;
}
...or at least that's what I see this as. Is that the right context?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think so. Going to dig into this now a bit more.
packages/core/src/types.ts
Outdated
/** | ||
* Meta information about a rule. | ||
*/ | ||
export interface RulesMeta { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similar to the note on RuleTextEditor
below, messages
and could be made generic. We do this in typescript-eslint's types so that rule tests have a type error reported if an incorrect message ID is used.
export interface RuleWithMeta<
Options extends readonly unknown[],
MessageIds extends string,
Docs = unknown,
> extends RuleCreateAndOptions<Options, MessageIds> {
meta: RuleMetaData<MessageIds, Docs>;
}
That Docs
generic is something to consider here too. Plugins can define their own docs, and we found often want to have their own properties on top of ESLint's. The RuleMetadata
type for docs
is an &
intersection of the provided Docs
and the ESLint standard RuleMetaDataDocs
. https://typescript-eslint.io/blog/announcing-typescript-eslint-v8#custom-rule-metadocs-types
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll need to look at this a bit closer. The code snippet you pasted above makes my head hurt -- I have a hard time reasoning about what it actually means.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah we're not huge fans of how complicated it's gotten ourselves 😄. But such is the complexity we've had to go with. A general summary if it helps...
What's happening is that each RuleWithMeta
has three "generic" parts that can be substituted in:
- What
Options
the rule has specified, which must be* areadonly
array - The
MessageIds
it can report, which must be* astring
- The shape of
Doc
umentation, which doesn't need to be explicitly specified - it defaults tounknown
*"must be" here is "assignable to": as in, whatever type we provide must satisfy that type:
readonly unknown[]
can be satisfied by, say,[]
or[{ allowInterfaces: boolean; allowObjects: boolean; }]
MessageIds
can be satisfied by, say,"noEmptyInterface" | "noEmptyObject"
It then extends
the interface RuleCreateAndOptions
, which defines the standard ESLint create()
method and typescript-eslint's defaultOptions
. Plus then it adds a meta
object of type RuleMetaData
.
Putting it all together, this is a rough subset of @typescript-eslint/no-empty-object-type
:
type NoEmptyObjectType = RuleWithMeta<
// Options
[
{
allowInterfaces: boolean;
allowObjects: boolean;
}
],
// MessageIds
"noEmptyInterface" | "noEmptyObject",
// Docs
{
recommended: 'recommended';
requiresTypeChecking: true;
}
>
packages/core/src/types.ts
Outdated
/** | ||
* An object containing visitor information for a rule. Each method is either the | ||
* name of a node type or a selector, or is a method that will be called at specific | ||
* times during the traversal. | ||
*/ | ||
export interface RuleVisitor { | ||
/** | ||
* Called for each node in the AST or at specific times during the traversal. | ||
*/ | ||
[key: string]: | ||
| ((node: unknown, parent?: unknown) => void) | ||
| ((...unknown: unknown[]) => void); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How will this interface be used? Will it be extended to declare language-specific interfaces where node
and parent
have a particular type, or will rules simply create and return a RuleVisitor
object?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's designed to be extended but still work as a default. (see the RuleDefinition
interface)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I tried to declare an interface that extends RuleVisitor
with something more specific, but it's not clear to me how the TypeScript syntax would look like (Playground link).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A possible solution: we could use generics in place of the actual argument types, e.g.:
export interface RuleVisitor<NodeType = unknown, ArgsType extends unknown[] = unknown[]> {
[key: string]:
| ((node: NodeType, parent?: NodeType) => void)
| ((...unknown: ArgsType) => void);
}
Implementors could specify those types explicitly:
interface MyRuleVisitor extends RuleVisitor<MyNode> {
[key: string]: (node: MyNode, parent?: MyNode) => void;
}
or
interface MyRuleVisitor extends RuleVisitor<never, [string, number, boolean]> {
[key: string]: (arg1: string, arg2: number, arg3: boolean) => void;
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The expectation is that people who extend RuleVisitor
will actually define the methods they want. These two methods are just fallbacks for cases where they miss something.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, I see. The problem is that if you extend RuleVisitor
as it is currently defined, TypeScript won't let you implement just some methods and it will require you to implement an index signature (for [key: string]
).
Then maybe RuleVisitor
should be just an empty interface so that it can be extended with arbitrary methods? I can add unit tests to verify that these types work as expected in a follow-up PR.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there any value in having an empty interface? Isn't that just like putting any
(or unknown
)?
I was looking for something where everyone wouldn't need to create an index signature for their own rule visitor. For methods with esquery selectors, they'd need to override [key: string]
to get a valid type for that kind of method.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's true, an empty interface can be assigned any value when the strictNullChecks
TypeScript option is not set, so it would be only useful as an indicator for people who read the code.
Another option is keeping the index signature with a generic function type, for example:
export interface RuleVisitor {
[key: string]: (...args: any) => void;
}
This would still not prevent implementors from adding methods that return non-void types.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Went with this.
I've updated the definitions for |
841b56f
to
44a5660
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK! Thanks for the ping. I took a dive and think this is all looking right. I can tell you took care to work with the types, and it's showing - this feels very clean 🙂.
I left a few comments or questions, but nothing I can find that should block.
packages/core/src/types.ts
Outdated
@@ -309,11 +688,16 @@ interface InlineConfigElement { | |||
/** | |||
* Represents the basic interface for a source code object. | |||
*/ | |||
interface SourceCodeBase { | |||
interface SourceCodeBase< | |||
LangOptions = LanguageOptions, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Question] Can LangOptions
be something that isn't assignable to LanguageOptions
? Put another way: is it intentional that there's no extends LanguageOptions
here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LanguageOptions
itself doesn't have any keys by default, so it seemed like extends LanguageOptions
wasn't necessary?
I was actually trying to reason through this choice, too, so any insights appreciated.
packages/core/src/types.ts
Outdated
* @param context The rule context. | ||
* @returns The rule visitor. | ||
*/ | ||
create(context: RuleContext<LangOptions, Code, RuleOptions>): Visitor; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
context: RuleContext<...>
[Question] This isn't passing an argument for the Node = unknown
type parameter. Is it intentional that RuleDefinition
wouldn't know about the type of nodes in its language?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not intentional, just missed it. Thanks!
packages/core/src/types.ts
Outdated
/** | ||
* Represents the context object that is passed to a rule. This object contains | ||
* information about the current state of the linting process and is the rule's | ||
* view into the outside world. | ||
*/ | ||
export interface RuleContext< | ||
LangOptions = LanguageOptions, | ||
Code extends SourceCode = SourceCode, | ||
RuleOptions = unknown[], | ||
Node = unknown, | ||
> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LangOptions, Code, RuleOptions, Node
[Comment] 👍 confirming that, as unwieldy as having four type parameters feels, I think this is the right way to represent things. Each rule definition is specific to the language, source code type, rule options, and language node.
I'll note that a somewhat-equivalent-ish syntax to represent passing many type parameters would be to pass a single type parameter extending a shape. Roughly:
/** | |
* Represents the context object that is passed to a rule. This object contains | |
* information about the current state of the linting process and is the rule's | |
* view into the outside world. | |
*/ | |
export interface RuleContext< | |
LangOptions = LanguageOptions, | |
Code extends SourceCode = SourceCode, | |
RuleOptions = unknown[], | |
Node = unknown, | |
> { | |
export interface RuleContextSettings { | |
LangOptions: LanguageOptions; | |
Code?: SourceCode; | |
RuleOptions: unknown[]; | |
Node?: unknown; | |
} | |
/** | |
* Represents the context object that is passed to a rule. This object contains | |
* information about the current state of the linting process and is the rule's | |
* view into the outside world. | |
*/ | |
export interface RuleContext<Settings extends RuleContextSettings> { |
...then later on referring to members of that type parameter:
- sourceCode: Code;
+ sourceCode: Settings["Code"];
It's conceptually like the refactor from a function with four parameter to a function with a single object parameter:
- function ruleContext(langOptions, code, ruleOptions, node) {
+ function ruleContext(settings) {
- langOptions;
+ settings.langOptions;
I've put up a comparison of what that could look like here: types-update-2...JoshuaKGoldberg:rewrite:types-update-settings-type-parameters.
Not saying either way is better, just showing another syntax as reference in case you prefer it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, interesting! I think I prefer the way it is, currently, but I consider myself educated. Thanks!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually, would that mean the type parameters could be specified in any order?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah! And you're forced to use explicit names for them. RuleContext<{ Key: Type, ... }>
instead of RuleContext<Type, ...>
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ooh nice! Okay, going to change to that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've switched all the types with way-too-may generic parameters to this model. The downside (as far as I can tell), is that TypeScript then either requires all of the properties or none of them -- the default is only used if nothing is passed, so I'm unable to assign defaults for each property. 🤔
@fasttime @JoshuaKGoldberg can you take another look? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK! I have no more inputs on the TypeScript types. Nicely done 🙁. This is a much more complex deep dive into TypeScript than most folks would want to get into at this stage of onboarding, I think, so it's great to see it so fleshed out.
A lot of the nuances (catch-all interfaces, Options extends *Options
, ...) will probably have to evolve over time as the team figures out its preferred TypeScript practices. I'm excited to see how that goes, it'll be interesting!
Co-authored-by: Francesco Trotta <[email protected]>
Co-authored-by: Francesco Trotta <[email protected]>
Co-authored-by: Francesco Trotta <[email protected]>
389c5cc
to
b9ad235
Compare
9d50aac
to
b9ad235
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM, thanks!
Prerequisites checklist
What is the purpose of this pull request?
Add types for rules that will work regardless of the language being used.
What changes did you make? (Give an overview)
I added several types that let us completely type check rules. These types are as generic as possible to allow for language plugins to override or better define their types, too.
These are not guaranteed to match the types in
@types/eslint
, which are very specific to the JavaScript language. The intent is to define something that will work and then see if we can backport into@types/eslint
in a way that still works with the JavaScript rules.Related Issues
Is there anything you'd like reviewers to focus on?
Does the naming make sense for the types? I tried to be more specific to avoid ambiguity (i.e.,
RuleFixer
is nowRuleTextEditor
).